Repository navigation
feat(storage,mcp): one upload link takes several files on a multi-file field - #33151
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
…e field A link for a multiple: true file field takes up to ten files in one submit, judged before the link is spent and landed in one write or not at all. A link for a record not yet created takes count and answers every id at once. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
📓 Docs Drift CheckThis PR changes 3 package(s): 53 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 42 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1c7e625a5fd922169d68e03aad4081cccbdac3e3 && git checkout 1c7e625a5fd922169d68e03aad4081cccbdac3e3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 47f6c939f132df9185e1259740e2cbe4156802e1 03dcadc63561eba754b2d93991e4877e1473a475 && git checkout -B drift-repro 47f6c939f132df9185e1259740e2cbe4156802e1 && git merge --no-ff 03dcadc63561eba754b2d93991e4877e1473a475
node scripts/docs-audit/affected-docs.mjs --json 47f6c939f132df9185e1259740e2cbe4156802e1
|
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Contract reviewServed-tier: ① Derived judgmentsMeasured against ruling A (6844223218), #30892 ruling B (a signed-in user, one user, an expiry) and #32686 ruling A (bound ids: same user, that object and field, one record, 1h).
② Semver level
③ Boundary flagsSeat conditions:
Break attempts:
Dev flags (PR body Acceptance notes; os-dev-report 6864752482):
Check-runs at head (50 read): 25 success, 15 skipped, 10 in progress ( Findings:
Implemented-by: VERDICT: PASS |
…d, refuse a form to a one-file link, read no part before the form is judged Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
…e-file link takes any body as the file, as before Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Contract reviewServed-tier: Round 2 is a delta review: ① Derived judgments
② Semver level
③ Boundary flags(a) Can a one-file link refuse or reorder anything main answered? No, for a single-file field link, an attachments link, or a
(b) Can the inspect read be raced into a wrong path? No, in a way that matters.
(c) Round-1 items
(d) Is the new pin genuine? Yes.
PR body (rewritten) vs code.
Check-runs at head (57 read): 40 success, 17 skipped, 0 failure, 0 in progress. Findings:
Implemented-by: VERDICT: CHANGES |
… any other body is the file on every link Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Contract reviewServed-tier: Round 3 is a delta on round 2 (comment 6866608002). I read Labels: F = must-fix (FAIL), S = should-fix, N = nit. Round-2 ids appear as r2-F-1 and so on. ① Derived judgments
② Semver level
③ Boundary flags(a) Main's answer holds for every unmarked body, on every link.
(b) The header reaches nothing main refused, beyond the accepted widening.
(c) Served teaching.
(d) Pins.
(e) PR body vs code.
Check-runs at head (57 read):
Findings:
Implemented-by: VERDICT: PASS |
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx 维护者速读 · PR #33151 @
|
Requested by Tesla · project thread
Fixes #32747
Clause-②: yes (widening)
Metadata-protocol: none
Skills-docs: updated here — the served MCP skill (
packages/mcp/src/skill-md.ts, thecreate_upload_linklines) andcontent/docs/ai/connect-mcp.mdxsay a multi-file field's link takes up to 10 files and explaincount;content/docs/permissions/system-context.mdxrow 47f names the reserved ids. No file underskills/**teaches upload links (git grep -i "upload.link\|create_upload_link" -- skills/finds nothing). The Console's skill download (objectstack-skill.ts) renders the no-scope skill, which carries no file tools, so its byte-identical mirror needs no change and its drift pin stays green.维护者速读
multiple: true)的上传链接,一次可以选并提交多个文件(最多 10 个),每个文件各得一个文件 id;单文件字段和附件链接仍然只收一个文件。fileIds)。record-file-access.ts的consumeUploadLinkFiles(检查顺序、一次消耗、失败清理),以及记录尚未创建时count预留 id 的那一段。record-file-access.integration.test.ts里#32747那一组(19 个用例),和 dogfood 的showcase-mcp-record-files(一个链接、三张 PDF)。What changes
The storage door: one submit, several files, all or nothing
consumeUploadLinkFiles(token, uploads[], caller)is the one path;consumeUploadLinkwraps it with a one-element array, so a one-file send takes the same steps in the same order as before, except on a multi-file field's record link, where the field'saccept/maxSizeis now judged before the spend (see Acceptance notes).fileRangeOf):UPLOAD_LINK_MAX_FILES(10) when the field declaresmultiple: true, else 1;count.The spec has no max-items key for a file field, so the cap is the platform constant. A wrong count is refused
400VALIDATION_ERRORthroughsendErrorbefore the link is spent, and says how many the link takes.413), and, on a link that takes several (a multi-file field's, or a link for a record not yet created), on the field's ownaccept/maxSize, however many files the send carries. A refusal stores nothing and leaves the link usable. A link that takes one file is judged as before (to a record, the record's write judges the field's constraints).[...current, ...ids]). Any store failing, or that write refusing (a pending approval's lock, say), discards every file of the send, rows and bytes, so the record shows what it held before.count(1–10, more than 1 only on amultiple: truefield). The mint reserves the extra ids aspendingrows namedupload-link (reserved), owned by the minting user, and answersfileIds: [link id, ...reserved]. The upload fills each id with the samepre_record_uploadbinding a single upload's id gets (that user, that object and field, one record, one hour). A failed mint takes the link and every reserved row back. A link whose stored reserved ids repeat one, or name the link's own id, is the one unusable link.PUT /upload-links/:tokendoor reads a several-file send as onemultipart/form-dataform carryingcountandfile0…fileN-1(indexed names, since a form parser that keeps one value per name keeps only the last of a repeated one), marked by the headerx-upload-link-form: several, which the page sends with it.multipart/form-data, carries the header, and the link takes several files: for such a body it reads the link first (a read that writes nothing), and the form declares at most as many files as that link takes, each within the upload limit, plus 64 KiB of framing.main: a multipart body without the header (on a multi-file field's link too), and the page's form sent to a link that takes one file (a single-file field's, an attachments link, acount: 1link). So does a marked form to a link the door cannot read: its spend answers the refusal it answered onmain, inmain's order.countover what the link takes is refused400VALIDATION_ERROR, and a form whose parts disagree withcount(or misses a part)400INVALID_REQUEST, both before any part's bytes are read.fileIds.file-attachment-service.ts(pass-through),storage-service-plugin.ts(the door resolver requires it),upload-link-wire.ts(the cap, its code and status, the form's field names).The page, the MCP tool, docs
upload-link-page.ts:<input type="file" multiple>where the link takes several; the script checks the pick count and sends the form. New sentences inupload-link-copy.tsfor every locale it serves (pick up to N; pick exactly N; N files attached / received; wrong count).create_upload_link(MCP): the description says a multi-file field's link takes up to 10 files, sent together, and that one refused file refuses the send. Optionalcount(1 to 10 in the schema) is offered where links for a record not yet created are;countwith arecordIdis refused before the bridge is asked. No new tool.packages/runtime/src/domains/mcp.ts: the bridge member's target type takescount.content/docs/ai/connect-mcp.mdx,content/docs/permissions/system-context.mdxrow 47f. Changeset:.changeset/32747-upload-link-multiple-files.md(patch: service-storage, mcp, runtime).Tests
New pins and what each refuses
packages/services/service-storage/src/record-file-access.integration.test.ts, describe#32747 — one link, several files(19 cases), on a fixture fieldclaim.scans(multiple: true, PDF only):count: 3: three ids, one submit fills them, the create takes all three;404, and it is not a link;400 VALIDATION_ERRORbefore the spend, then 10 land;400 VALIDATION_ERROR, "takes one file"; the door never reads a form for them), then the one lands, then the link is gone (404); and the mint refusescountof 0, 11 or 1.5, and above 1 on a single-file field (400 VALIDATION_ERROR);404and spends nothing;ERR_FILE_CONSTRAINT), the record unchanged, the link usable;500, the record unchanged, no row and no bytes of the send left, and the key whose upload threw deleted too (a record link and a pre-record link);409, the record unchanged, none of the files kept;400 INVALID_REQUEST, nothing spent;400 ERR_FILE_CONSTRAINTbefore the spend, nothing stored, the link then takes a PDF;multipart/form-databody as the file, as onmain: declared 1 MiB + 1 under a 1 MiB limit is413withmain's words (a form's allowance would have taken it), and the body is stored with its own length as the size;413with the same words;count: 1link:413withmain's words over the one-file limit, else200with the body's length as the size;413withmain's words, then404;404, nothing stored;count11 andcount999 (400 VALIDATION_ERROR), parts fewer thancountand a missingfile1(400 INVALID_REQUEST), each with zero reads; a good form reads each part once;multipleon a multi-file link, the exact-count sentence on acountlink, nomultipleon a single-file link, and its form sent withx-upload-link-form: several.packages/mcp/src/mcp-file-tools.test.ts, describe[#32747] create_upload_link: one link, several files(5 cases):countpasses to the bridge and the answer names every id; nocountasks the bridge as before;countwith arecordIdis refused before the bridge;countis offered only where a link can name no record, and the description says a multi-file link takes several; the served skill teaches both.packages/qa/dogfood/test/showcase-mcp-record-files.dogfood.test.ts, describe[#32747] one link, three PDFs (dogfood_upload_claim.invoices)(3 cases, a realFormDatathrough the served stack): a claim link takes three PDFs andread_filereads each;count: 3andcreate_recordtakes the three ids; one PDF of three over the size limit refuses the send (413, nothing stored), then the same link takes the three.Two existing expectations in the integration file now include the additive
fileIds; one dogfood expectation for #32686 does too.Ablations: each new negative pin, with the forbidden behaviour put back
Each through
node scripts/ablation-replace.mjs(the anchor must hit, the mutation is verified on disk, the file is restored to HEAD andgit diff HEADis empty after each):accept/maxSizebefore the spend only for a pre-record linkcommittedinstead ofpendingcountabove 1 on a single-file fieldcountabove 10countwith arecordIdpassed to the bridge400 INVALID_REQUESTinstead of413)413pin (1 failed:404before413)countover the link's cap not refused in the doorcountwithout.max(10)dist, dogfood)200, three files stored)Acceptance notes
maintook as the file is still the file, on every link, except amultipart/form-databody carryingx-upload-link-form: several(a headermainnever defined) sent to a link that takes several files, which is read as the form. Every other body takes the same path, checks, limit and order as onmain, with the same codes and statuses. The one answer change is additive: every success answer also carriesfileIds.accept/maxSizebefore the spend, with the same code and status the record's write answered (400 ERR_FILE_CONSTRAINT), and the link stays usable instead of spent.400 VALIDATION_FAILED,invalid_value, "its upload was never completed"); any other user's or a system write by the pre-record arm (not_yours, the unreadable-file words), since the reserved row is recognised as a link's row. The download door answers404 FILE_NOT_FOUND(apendingfile is not served),read_filereads only files a record holds, and the row ispending, sosys_file's retention (status: 'pending', 7 days) reaps it if the link is never used. The link's own id keeps its existing refusals.sys_filerow or stored bytes of the send are left, for a record link and a pre-record link. A record write refusing after the spend (a lock) is pinned the same way.count(Q1).mcp-http-tools.ts,storage-routes.ts,upload-link-copy.ts,pre-record-upload.ts,skill-md.ts) were read; the ones describing a link's use are updated, the rest describe a single upload or file and stay.Verification
Commands and exit codes
pnpm --filter @objectstack/service-storage exec vitest run— 83 files, 1573 tests pass (at03dcadc6).pnpm --filter @objectstack/mcp exec vitest run— 63 files, 1109 tests pass.pnpm --filter @objectstack/dogfood exec vitest run test/showcase-mcp-record-files.dogfood.test.ts— 21 of 21 pass.pnpm --filter <pkg> run typecheckfor service-storage, mcp, runtime and dogfood — exit 0 each.node scripts/pm/dispatch-gates.mjs --commands— 106 derived commands plus the 4 artifact-roster gates whose roster sits under a changed directory (check:changeset-fixed,check:error-code-casing,check:filter-alias-parity,check:route-ledger-census): all 110 exit 0, each run with</dev/null.check:docs,check:skill-examples(exit 1 / 3:json-schemaand declarations older than the mergedpackages/spec/src) andcheck:dual-build-cjs-loads(exit 3: packages not built yet) were rerun afterpnpm --filter @objectstack/spec buildand the builds, exit 0.--ranreconciliation: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint— exit 0.03dcadc6: the same 106 derived commands and 4 roster gates, all 110 exit 0;--ran106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN;pnpm lintexit 0; typecheck service-storage and dogfood exit 0; dogfood file 21 of 21 after rebuilding service-storage.5cc65412: the same 106 derived commands and 4 roster gates, all 110 exit 0;--ran106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN;pnpm lintexit 0; typecheck service-storage exit 0; dogfood file 21 of 21 after rebuilding service-storage.501eafef: the same 106 derived commands and 4 roster gates, all 110 exit 0 first time;--ran106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN;pnpm lintexit 0; typecheck service-storage and mcp exit 0; dogfood file 21 of 21 after rebuilding service-storage and mcp.origin/mainmerged once before this PR (merge6f09953b; no file in common with this change).🤖 Generated with Claude Code
https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ